一例 Go 编译器代码优化 bug 定位和修复解析
摘要
本文中介绍了 Go 编译器的整体编译流程脉络和一个编译优化错误导致数据越界访问的 bug,并分析了对这个 bug 的排查和修复过程,希望能够借此让大家对 Go 编译器有更多的了解,在遇到类似问题时有排查思路。
缘起
某日,一位友人在群里招呼我,“看到有人给 Go 提了个编译器的 bug,挺有意思,感觉还挺严重的,要不要来看看?”于是我打开了 issue 40367[1] 。彼时,最新一条评论是 这条[2] :
提到将循环体中的一个常数从 1 改成 2 就无法复现问题,这顿时勾起了我的兴趣,于是我准备研究一番。
bug 代码跟现象如下图,正常来看,代码应该在输出 "5 6" 后停止,然而实际上却无限执行了下去,只能强行终止或等待程序触碰到无权限内存地址之后崩溃。
首先,我们要定位到这个问题具体的直接原因。简单来说,这个 bug 是 for-range loop 越界,原本循环应该在循环次数到达数组长度后终止,但是这个复现程序中的循环无限执行了下去。乍一看,问题像是有 bound check 被优化掉了,那么我们来实锤一下。有一个方便的网站,可以在线观察给定程序编译产出的汇编结果,我用 这个网站[3] 分别生成了原复现程序和将第六行的 +1 改为 +2 后不复现程序的汇编,供大家对比。抛开无关细节不提,可以很容易地看到前者的汇编相较于后者的确少了一次判断,导致循环无法终止,具体的位置是第二段代码的 105 行:
背景知识
在追踪这个具体问题之前,我们需要先了解一些相关知识背景。
Go 编译器的大体运行流程
想要追查 Go 编译器的问题,首先就需要了解 Go 编译器的大致运行流程。其实 Go 的编译器的实现中规中矩,相比于 GCC/Clang 等老牌编译器甚至有些简陋,许多优化并未实现。一个 Go 程序在生成汇编前的工作大概分为这几步:
语法解析。由于 Go 语言语法相当简单,所以 Go 编译器使用的是一个手写的 LALR (1) 解析器,这部分跟今天的 bug 无关,细节略过不提。 类型检查。Go 是强类型静态类型语言,在编译期会对赋值、函数调用等过程做类型检查,判断程序是否合法。另外,这个步骤会将一些 Go 自带的泛型函数变换成具体类型的函数调用,比方说 make 函数,在类型检查阶段会根据类型检查的结果变换成具体的 makeslice/makemap 等。这部分也跟今天的 bug 无关。 中间代码 (IR)生成。为方便做跨平台代码生成,也为方便做编译优化,现代编译器通常会将语法树变成一个中间代码表示形式,这个表示形式的抽象程度通常是介于语法树和平台汇编之间。Go 选择的是一种静态单赋值 (SSA)形式的中间代码。这部分较为重要,接下来一个小节会展开详述一下。 编译优化。在生成了 SSA IR 之后,编译器会基于这个 IR 跑很多趟(pass)代码分析和改写,每个 pass 会完成一个优化策略。另外值得一提的是,Go 中很多强度削减类的策略是使用一种 DSL 描述,然后代码生成出实际的 pass 代码来的,不过这块跟今天内容没什么关系,感兴趣的同学可以下来看看。在文章的后续内容中,我们就会定位到导致本文中这个 bug 的具体的 pass,并看到那个 pass 中有问题的逻辑。
这几步之后,编译器就已经准备好生成最终的平台汇编代码了。
静态单赋值形式
静态单赋值的含义是,在这种类型的 IR 中,每一个变量只会被赋值一次。这种形式的好处我们不再赘述,仅以一段简单的 Go 代码作为实例帮助大家理解 SSA IR 的含义。
但是,如果是代码中包含了 if 等语句,在编译时不能确定需要使用哪个值,在 SSA IR 中如何表示呢?例子中正好有这样的代码,可以看到 Go 代码中第六行的 if。实际上,SSA IR 中有一个专门的 phi 操作符,就是为了这种情况设计,phi 操作符的含义是,返回值可能是参数的多个值中的任意一个,但是具体究竟是哪个值,需要取决于这个 block 此次运行是从哪个 block 跳转而来。在上图中,可以看到 b2 就有一个 phi 运算符,v22 可能等于 v11 或 v21,具体等于哪个值需要看 b2 的上一个块究竟是 b1 还是 b3,实际上就对应了 if 条件的成立或不成立。当然,这个例子中 if 显然成立,只不过我们这里看到的 SSA IR 是未经过优化的 IR,在实际的编译过程中,这里会被优化掉。
Go 编译器提供了非常方便的功能,可以查看各个优化 pass 前后的 SSA IR,只需要在编译时,增加一个 GOSSAFUNC=xxx 环境变量即可,xxx 即为想要分析的函数的名字,因为 Go 编译器内部的优化都是函数级别的。比如上图的例子,只需要运行 GOSSAFUNC=main go build ssaexample.go,编译器就会将 SSA IR 结果输出到当前目录的 ssa.html 中,用浏览器打开即可。
排查过程
追查出问题的优化策略
了解了这么多前置知识,我们终于可以来追查这个具体的 bug 成因了。第一步,我们要首先通过 Go 编译器 dump 出来的 SSA IR,查看究竟是哪一个 pass 出了问题。用上一节中讲到的方式,我们可以观察 issue 中的复现程序的所有 SSA IR。由于 Go 编译器的优化 pass 不少,所以在 ssa.html 中记录了大量的 SSA IR,我们如何找到有问题的 pass 呢。对于我个人来说,由于我之前有所了解,能够大致猜到这种问题是 prove pass 的 bug。但是即使大家没有相关背景,由于我们已经知道这个 bug 的直接原因是少了一条比较判断,所以也可以通过二分法查看哪个 pass 少了一条比较指令来进行定位。需要注意的是,大家可能会定位到 generic deadcode pass,因为这个 pass 中少了一条 Less64 指令,如图(我这里使用的是 Go 1.15rc1,具体输出与编译器版本相关,可能有所不同),右侧是 generic deadcode pass:
右侧是 prove pass,可以看到这行在 prove pass 变成了灰色。
prove pass 简介
定位了出问题的策略是 prove pass,那么接下来我们就需要看看 prove pass 究竟是干什么用的。实际上,prove pass 的功能是对全局中 SSA 值的取值范围做一个推断,这样就可以消除掉许多不必要的分支判断,是不是听起来就跟今天的 bug 脱不了干系?实际上,这是在 Go 编译器中非常重要的一个 pass,很多优化都依赖于这个 pass 之后得到的结果。比如,由于 Go 是内存安全的语言,所以所有的 slice 取元素操作都需要做一个检查,来判断取元素用的下标是否超出了 slice 的范围,这个操作叫做 bound check。但是实际上,很多代码中在编译期就能确定这个下标是否越界,那么我们就可以将原本需要在运行期做 bound check 的检查给消除掉,这步优化叫做 bound check elimination,具体代码示例比如下面这段,是从 Go 标准库[4] 拿来的代码:
func (bigEndian) PutUint64(b []byte, v uint64) {
_ = b[7] // early bounds check to guarantee safety of writes below
b[0] = byte(v >> 56)
b[1] = byte(v >> 48)
b[2] = byte(v >> 40)
b[3] = byte(v >> 32)
b[4] = byte(v >> 24)
b[5] = byte(v >> 16)
b[6] = byte(v >> 8)
b[7] = byte(v)
}
可以看到,这个函数中首先进行了 b[7] 的操作,这样一来,编译器在 prove pass 就可以了解到,当程序运行到第三行及之后时,slice b 的长度是必然大于等于 7 的,因此后续操作的 bound check 都可以被 eliminate 掉。 但是,prove pass 不止会做 bound check elimination 这一个特定 pattern 的优化,还有许多其他 pattern 也会在 prove pass 被优化掉。那么今天的这个 bug 究竟是 prove pass 中什么地方出了问题呢?
prove pass 问题排查
说起代码问题的定位方法,可能大体上能够分成三个流派。第一是打日志,通过在日志中加信息来定位问题;第二是通过 gdb 等 debugger 下断点、单步运行来排查问题;第三是动态追踪,通过 perf/systemtap/ebpf 之类的手段来动态观测程序运行时的行为。具体到 Go 编译器这里,其实开发 Go 编译器的 Go team 大牛们也需要日常排查问题,也不外乎这几种手段,但是在编译优化的问题上他们更青睐第一种打日志的方式,所以他们已经在各个 pass 中预埋了许多 debug 日志,只是这些日志平常不会开启,需要特殊的编译开关。既然 prove pass 相当复杂,我们不妨通过查日志的方式来进一步缩小问题排查范围。prove pass 的 debug 日志开关是 -d=ssa/prove/debug=1,其中 debug 后面跟的数字越大日志越详细,我们只需要在编译时执行 go tool compile -d=ssa/prove/debug=1 bug.go 就能看到对应的日志。具体到这个 bug,用 debug=1 的级别能够看到对比。如下图,左侧为复现程序的日志,右侧为修改常量后不复现的程序的日志:
上图为问题复现代码的 ssa cfg 图,能够清楚地看出,b6 没有与对应的 b5 有直接关联,而是间接关联,命中了代码的错误路径。
结尾
定位到了问题,那么如何修复呢?一个很简单的方式,就是直接在 br 求值的逻辑后面,增加一个 unknown 判断逻辑,当 br == unknown 就直接退出判断。这样一来,prove pass 显然会变得保守,但是可以保证正确性。加了这个检查之后 bug 复现程序就运行正常了,但是作为更加 general 的修复,我们在函数的入口处增加对入口 block 的判断,确保入口 block 的确是一个循环开头块,而不是什么别的恰好也能匹配上当前 pattern 的东西。我将这个修复提交给了上游。这个 bug 由于非常严重,而且这个修复对性能实测基本没有太大影响,所以很快合入了 master,即 commit 7f8608047644ca34bad1728d5e2dbef041a1b3f2[6] ,并且将要 cherry pick 到仍然承诺维护的前两个大版本 1.13 和 1.14 中。前面提到,这个 patch 会让优化器更加保守,所以后续会通过其他修改让优化器恢复到之前的水平,我也已经提交了对应的 patch,不过由于 1.15 开发周期已经冻结,所以预计会在 1.16 cycle 合入 master。
相信大家通过本文已经对 Go 编译器的运行过程、定位 bug 的一些方式有了基本的了解。可能大家已经注意到,开头我提到这个 bug 的复现程序在修改一个常数为 2 之后就不再复现,那到底是什么原因导致修改常数之后就不复现了呢?相信细心的你经过研究之后知道了答案。Happy hacking ;-)
加入我们
我们是字节跳动基础架构的图平台团队,专注于构建世界一流的分布式图数据库和大规模图计算平台,支持抖音、今日头条等社交、推荐、风控等关键业务,致力于解决海量并发下 social graph 架构、推荐架构、用户画像、知识图谱等场景下的关键技术问题,把有趣的分布式系统技术落地,服务 10 亿级别用户,在持续招聘中。内推联系邮箱: tech@bytedance.com ;邮件标题: 姓名 - 工作年限 - 基础架构 - 图平台 。
参考资料
更多分享
字节跳动破局联邦学习:开源Fedlearner框架,广告投放增效209%
iOS 性能优化实践:头条抖音如何实现 OOM 崩溃率下降50%+
欢迎关注「 字节跳动技术团队 」
简历投递联系邮箱「 tech@bytedance.com 」